ROS2 消息序列化:从 .msg 到 CDR 字节流
ROS2 的通信建立在 DDS 标准之上,一条消息从 publish() 到达对端,中间要经过代码生成、类型支持、CDR 编码三层机制。本文自顶向下拆解整条序列化管线:rosidl 如何把 .msg 变成 C++ 结构体,CDR 线格式如何对齐与编码,两种类型支持策略的差异在哪里,rmw_cyclonedds 适配器内部的自定义序列化如何实现,内省类型支持又如何在运行时支撑 GenericPublisher 的类型无关发布,以及零拷贝 loaned message 的生效条件。适合写过 ROS2 节点、想理解消息传输开销来源或做中间件适配的读者。
rosidl 代码生成管线
从 Dashing 开始,ROS2 的消息生成管线基于 .idl 中间表示:
.msg 文件 → rosidl_parser (解析) → rosidl_adapter → .idl (OMG IDL 4.2)
↓
┌──────────┼──────────┐
↓ ↓ ↓
generator_cpp typesupport introspection
(C++ 结构体) (类型支持) (内省元数据) 以 std_msgs/msg/String 为例,rosidl_generator_cpp 生成三类头文件(较新的发行版中后两类位于 msg/detail/ 子目录):
| 文件 | 内容 |
|---|---|
string.hpp | 顶层头文件,用户代码 include 这个 |
string__struct.hpp | 消息 struct 定义,字段即成员变量 |
string__traits.hpp | 编译时类型特性:is_message、has_fixed_size、has_bounded_size |
生成的结构体是 plain struct,所有实现均在头文件中(header-only)。
ROS 类型到 DDS IDL 类型按固定映射转换:
| ROS 类型 | DDS IDL 类型 | Wire 大小 |
|---|---|---|
bool | boolean | 1 字节 |
byte / uint8 | octet | 1 字节 |
float32 | float | 4 字节 |
float64 | double | 8 字节 |
int32 | long | 4 字节 |
int64 | long long | 8 字节 |
string | string | 变长 |
T[] | sequence<T> | 变长 |
T[N] | T[N] | 固定 |
Service 侧同理,一个 .srv 文件生成两个 .idl:
MyService.srv → MyService_Request.idl + MyService_Response.idl CDR 线格式
封装头
每条 CDR 消息开头 4 字节,是 16 位 representation identifier(大端)加 16 位 options(通常为 0):
字节 0-1: 编码格式标识 (representation id, 大端)
0x00 = PLAIN_CDR Big Endian (XCDR1)
0x01 = PLAIN_CDR Little Endian (XCDR1) ← ROS2 默认
0x02 = PL_CDR Big Endian (参数列表,发现数据用)
0x03 = PL_CDR Little Endian
0x08/0x09 = PLAIN_CDR2 Big/Little Endian (XCDR2, final 类型)
0x0a/0x0b = DELIMITED_CDR2 Big/Little Endian (XCDR2, appendable 类型)
0x10/0x11 = PL_CDR2 Big/Little Endian (XCDR2, mutable 类型)
字节 2-3: options 字段(通常为 0) 抓包时看到 ROS2 序列化消息开头多出 4 字节,就是封装头。
对齐规则
每个基本类型按自然边界对齐,不足处补 padding:
struct Example {
uint8 a; // offset 0, 1 字节
// 3 字节 padding
uint32 b; // offset 4, 4 字节
uint16 c; // offset 8, 2 字节
// 6 字节 padding
double d; // offset 16, 8 字节
}; // 总计 24 字节 XCDR1 与 XCDR2 的关键差异在对齐上限:XCDR1 中 double、int64 等 64 位类型按 8 字节对齐,XCDR2 把所有基本类型的对齐上限收紧到 4 字节。这也是含 8 字节成员的 FINAL 类型在两种编码间不互通的原因——即使类型结构完全相同,padding 布局也不同。
复合类型编码
String:[4 字节长度 N (uint32 LE)][N 字节 UTF-8 数据 + NUL 终止符]。长度包含结尾的 \0:"hello" 编码为 06 00 00 00 68 65 6C 6C 6F 00(6 = 5 个字符加 1 个终止符)。
Sequence:[4 字节元素个数][元素1][元素2]...
Array:连续存储,无长度前缀。
Nested struct:递归应用 CDR 规则,从第一个字段的对齐要求开始。
两种类型支持策略
ROS2 的 RMW 实现有两种获得序列化能力的方式,直接影响性能特征:
静态类型支持
- 编译时为每个消息类型生成专用序列化/反序列化函数
- 每个
.msg生成独立的函数实现,直接访问结构体成员 rmw_fastrtps_cpp使用此方案
.msg → .idl → FastRTPS 代码生成器 → 专用的 serialize()/deserialize() 内省类型支持
- 编译时生成消息的元数据描述(成员名、类型、偏移量)
- 运行时通过遍历元数据,用通用函数处理任意消息类型
rmw_cyclonedds_cpp使用此方案;CycloneDDS 没有对应的每消息代码生成包,消息包无需为它重新编译
核心数据结构:
// 描述整个消息
struct MessageMembers {
const char* message_namespace_;
const char* message_name_;
uint32_t member_count_;
const MessageMember* members_; // 字段描述数组
size_t size_of_; // struct 的 sizeof
};
// 描述单个字段
struct MessageMember {
const char* name_;
uint32_t type_id_;
size_t offset_; // 在 struct 中的偏移
bool is_array_;
uint32_t array_size_;
}; 对比
| 方面 | 静态 (FastRTPS) | 内省 (CycloneDDS) |
|---|---|---|
| 序列化函数 | 每类型独立生成 | 通用函数 + 元数据遍历 |
| 运行时开销 | 无(直接访问成员) | 有间接调用和遍历 |
| 编译时间 | 较长 | 较短 |
| 二进制体积 | 较大 | 较小 |
| 类型发现 | 不支持 | 支持运行时发现 |
内省类型支持的运行时消费者:GenericPublisher
内省类型支持不仅服务于此处讨论的序列化,还有一类直接消费者:rclcpp 的 GenericPublisher / GenericSubscription。它们在编译期不知道消息类型的情况下实现类型无关的发布订阅——ros2 topic echo、rosbag2 的 Player/Recorder 都建立在这组类之上。
运行时加载类型支持
GenericPublisher 不是模板类,直接继承 PublisherBase,消息类型在运行时从类型字符串(如 "std_msgs/msg/String")动态解析,分三步(typesupport_helpers.cpp):
extract_type_identifier()把类型字符串拆成package_name/middle_module/type_name三部分get_typesupport_library_path()通过 ament 索引定位类型支持库文件(如libstd_msgs__rosidl_typesupport_cpp.so),get_typesupport_library()用rcpputils::SharedLibrary动态加载get_message_typesupport_handle()在库内按符号名查找并取得类型支持句柄,符号命名规则为{typesupport_identifier}__get_message_type_support_handle__{package}__{module}__{type}
加载成功的库由成员 ts_lib_(std::shared_ptr<rcpputils::SharedLibrary>)持有,保证发布器存活期间类型支持库不被卸载。
直接发布序列化消息
类型未知意味着拿不到结构体,GenericPublisher 的 publish() 直接发布序列化消息:
rcl_publish_serialized_message(
get_publisher_handle().get(),
&message.get_rcl_serialized_message(), NULL); 此外提供 publish_as_loaned_msg() 零拷贝路径:从中间件借出消息内存(borrow_loaned_message())、把序列化数据反序列化进去(deserialize_message())、直接发布借出的消息(publish_loaned_message()),避免中间拷贝。
限制
- 不支持进程内(intra-process)通信:类型在编译期未知,无法走进程内传递的模板化路径,官方头文件注释明确说明
- 动态加载库与序列化/反序列化都有额外开销
- 编译器无法检查类型匹配,类型错误只能在运行时发现
完整数据流
发布路径
接收路径
rmw_cyclonedds 的序列化实现细节
游标体系与两阶段序列化
序列化基础设施位于 Serialization.cpp,核心是一组基于 CRTP 模板的游标类(静态分发,非虚函数继承):
CursorBase<Derived>/WriteCursorBase<Derived>:提供align()等通用操作的模板基类,各具体游标自行定义offset()、advance()、put_bytes()SizeCursor:只计算偏移量、不实际写入数据(put_bytes仅前进),ignores_data()返回 true;变体CheckingSizeCursor在累计大小超过 UINT32_MAX 时抛std::overflow_errorSerializeCursor:直接写入给定内存位置(memcpy),ignores_data()返回 falseByteVectorCursor:向std::vector<byte>追加数据DeserializeCursor:读方向的游标,带 origin/position/end 指针与越界检查
序列化按两阶段进行:
- SizeCursor:遍历消息成员元数据,计算 CDR 编码后的总大小(不写数据)
- SerializeCursor:按算好的大小分配 buffer,再遍历成员写入实际数据
这样先算大小再一次性分配,避免了动态 buffer 扩容和多次内存分配。
Simple Type 快速路径
对于 CDR 兼容内存布局的类型(所有成员是 primitive 且无对齐问题),直接 memcpy 跳过逐成员遍历:
首次序列化 → 检测消息是否 simple type → 缓存结果
后续序列化 → 命中缓存 → memcpy 整个消息体 serdata 操作表
CycloneDDS 的自定义类型通过 ddsi_serdata_ops 操作表注册(序列化数据级的操作;类型级的操作表是另一个结构 ddsi_sertype_ops):
static const struct ddsi_serdata_ops serdata_ops = {
.get_size = ..., // 获取序列化大小
.from_sample = ..., // 从 ROS2 消息构造 CDR 数据
.to_sample = ..., // 从 CDR 数据恢复 ROS2 消息
.free = ..., // 释放资源
// ...
}; serdata_rmw:与 CycloneDDS 的集成层
虽然序列化逻辑完全自定义,数据包装却严格遵循 CycloneDDS 的接口规范(serdata.hpp / serdata.cpp):
serdata_rmw类继承自ddsi_serdata(CycloneDDS 的标准数据接口),持有序列化数据缓冲区m_data与大小m_sizeserdata_rmw_serialize_into():将 ROS 消息样本序列化到 serdata 缓冲区;普通消息与服务请求的区分在 sertype 层(SampleOrRequest)完成serdata_rmw_from_ser():从网络接收的 fragment chain 逐段拷贝重组,创建 serdata 对象并从序列化数据提取 keyserdata_rmw_from_ser_iov():从 iovec 数组创建 serdata 对象,支持零拷贝接收路径
为什么需要自定义序列化
直接复用 CycloneDDS 自身的序列化满足不了 ROS 的需求:
- 线格式差异:ROS 消息的 CDR 封装(encapsulation header、服务请求头)与 CycloneDDS 内部序列化格式不同,产出必须符合 ROS 语义才能与其他 DDS 实现互通
- 内省类型支持的直接消费:CycloneDDS 没有每消息的代码生成包,rmw_cyclonedds 通过 rosidl 生成的内省元数据遍历字段直接读写 CDR,避免中间转换层
- 服务通信扩展:请求/响应关联头信息的注入
- 高效路径:iovec 接收、loaned message 等零拷贝优化都建立在这套自定义实现之上
rmw 序列化 API
手动序列化(调试、跨进程传递消息)直接用 rmw 接口:
// 序列化:ROS 消息 → CDR 字节流
rmw_ret_t rmw_serialize(
const void* ros_message,
const rosidl_message_type_support_t* type_support,
rmw_serialized_message_t* serialized_message);
// 反序列化:CDR 字节流 → ROS 消息
rmw_ret_t rmw_deserialize(
const rmw_serialized_message_t* serialized_message,
const rosidl_message_type_support_t* type_support,
void* ros_message); rmw_serialized_message_t 本质上是 CDR 编码的字节缓冲区。
零拷贝:Loaned Message
传统路径与零拷贝路径
传统路径:用户消息 → memcpy → CDR 序列化 → DDS 发送,至少两次拷贝。
零拷贝路径:DDS 预分配 loaned buffer → 用户直接写入 → 共享内存传输。
关键 API
// 发布端借入
rmw_borrow_loaned_message(publisher, type_support, &loaned_msg);
// 写入数据后直接发布(发布后自动归还)
rmw_publish_loaned_message(publisher, loaned_msg, allocation);
// 订阅端借入
rmw_take_loaned_message(subscription, &loaned_msg, &taken, allocation);
// 用完释放
rmw_release_loaned_message(subscription, loaned_msg); 限制条件
- 仅支持固定大小类型(不含
string/sequence等变长字段) - CycloneDDS 基于 iceoryx 实现,需 XML 启用
SharedMemory - QoS 限制(任一不满足则退回网络/拷贝路径):Reliability = RELIABLE、Durability = VOLATILE、History = KEEP_LAST(深度不超过配置的池容量)、Liveliness = AUTOMATIC、Deadline = INFINITY
- 环境变量
ROS_DISABLE_LOANED_MESSAGES=1可全局禁用
固定大小检测
通过 is_type_self_contained() 判断:
- 不含
string/wstring(变长类型) - 不含无界
sequence - 所有嵌套子消息也必须 self-contained
要点
- 序列化发生在 RMW 实现层,通过 TypeSupport 完成:静态方案每类型生成专用函数,内省方案运行时遍历元数据,二者在编译时间、二进制体积和运行时开销上互补
- 封装头解释了消息开头的 4 字节;XCDR1 与 XCDR2 的对齐上限差异(8 字节 vs 4 字节)意味着含 64 位成员的 FINAL 类型在两种编码间不互通
- rmw_cyclonedds 的序列化是自定义实现:CRTP 游标体系两阶段进行(SizeCursor 算大小、SerializeCursor 写数据),通过继承
ddsi_serdata集成进 CycloneDDS,产出仍是标准 CDR - 内省类型支持支撑了运行时的类型无关发布:GenericPublisher 动态加载类型支持库、直接发布序列化消息,代价是不支持进程内通信与额外的运行时开销
- 序列化的主要开销来自逐成员遍历、对齐 padding 计算和变长字段内存分配;纯 primitive 且布局兼容 CDR 的类型可走 memcpy 快速路径,真正跳过序列化只有零拷贝路径
- 零拷贝的条件是严格的:固定大小类型、iceoryx 共享内存、五项 QoS 全部满足;条件不满足时自动回退普通路径,功能不受影响
参考资料
- RTI Connext Extensible Types Guide, Ch.4 Data Representation(XCDR/XCDR2 编码规则)
- OMG DDS-XTypes issue tracker(XCDR2 对齐上限 4 字节的差异)
- Eclipse CycloneDDS 官方文档:Loan 机制与零拷贝 QoS 限制
- ROS 2 设计文章:Zero Copy via Loaned Messages
- Creating an RMW Implementation(静态/内省类型支持策略,ROS 2 Jazzy 教程)
- rmw_cyclonedds 源码:Serialization.cpp / serdata.cpp(游标体系与集成层)